返回文章列表

缓存雪崩、击穿、穿透:三种异常怎么区分,怎么防

缓存是 Redis 最常见的用途,但缓存不是银弹。生产环境上,缓存相关的故障往往比"Redis 挂了"更隐蔽,也更难排查。

缓存雪崩、击穿、穿透:三种异常怎么区分,怎么防

缓存是 Redis 最常见的用途,但缓存不是银弹。生产环境上,缓存相关的故障往往比”Redis 挂了”更隐蔽,也更难排查。

最常见也最容易混淆的,就是缓存雪崩、缓存击穿、缓存穿透。名字很像,但真正出问题时,三者的流量形状、数据库压力点和处理办法都不一样。这里继续沿用前面的商品缓存场景,只把一件事讲清楚:到底是哪一类请求把缓存体系打坏了。

先给一张图:三种异常的根本区别

三种缓存异常对比

先用这张图区分三种异常:雪崩看的是“面”,击穿看的是“一个热点”,穿透看的是“根本不存在的数据”。

06 缓存雪崩、击穿、穿透 图 1

一句话区分:

  • 雪崩:大面积的缓存同时失效;
  • 击穿:单个热点 key 过期被高并发打穿;
  • 穿透:查询的 key 根本不存在,缓存形同虚设。

下面逐个展开。

一、缓存雪崩:大面积缓存失效,DB 瞬间被打爆

缓存雪崩指的是大量缓存 key 在同一时间集中失效,或者Redis 实例宕机,导致大量请求同时打到后端数据库,数据库在短时间内承受不住而崩溃。

在电商系统里,这种情况常见于:

  • 大型促销活动前,批量预热了大量商品缓存,设置了相同的 TTL;
  • 活动结束时,这些缓存同时过期;
  • 用户请求瞬间全部穿透到数据库。
06 缓存雪崩、击穿、穿透 图 2

雪崩的根本原因是缓存失效时间过于集中。即使不是批量预热导致的,Redis 实例宕机也会造成类似效果——所有 key 同时不可访问。

雪崩的防护手段

缓存雪崩防护策略

这张图可以当成雪崩防护的速查卡:把过期时间打散、把热点单独照顾、把回源并发卡住、必要时直接降级。

1. TTL 加随机偏移

这是最朴素也最有效的手段。在设置缓存时,给 TTL 加上一个随机值,让过期时间分散开:

// 基础 TTL 300 秒,加上 0-300 秒的随机偏移
int ttl = 300 + ThreadLocalRandom.current().nextInt(300);
redis.setex(key, ttl, value);

这样即使批量写入了缓存,过期时间也会分散在 5-10 分钟内,避免同一秒集中失效。

2. 热点 key 永不过期

对于真正热点的 key,比如首页推荐商品、秒杀商品信息,可以不设置 TTL,而是通过后台异步线程定期刷新缓存。这样热点数据永远不会出现”过期瞬间”。

// 不设 TTL
redis.set(key, value);

// 后台任务每 5 分钟刷新一次
@Scheduled(fixedRate = 300_000)
public void refreshHotKeys() {
    for (String hotKey : hotKeys) {
        String freshValue = loadFromDB(hotKey);
        redis.set(hotKey, freshValue);
    }
}

3. 多级缓存

在 Redis 之外再加一层本地缓存(如 Caffeine、Guava Cache)。当 Redis 不可用时,本地缓存还能扛一段时间,为 Redis 恢复或数据库降级争取时间。

用户请求 -> 本地缓存 -> Redis -> 数据库

4. 熔断和降级

当数据库压力达到阈值时,通过熔断器(如 Sentinel、Hystrix)直接拒绝部分请求,保护数据库不被打挂。牺牲一部分用户体验,保住核心链路。

5. 高可用架构

Redis 实例本身也要做高可用:主从复制 + 哨兵自动故障转移,或者 Redis Cluster。单实例宕机时,从节点可以快速提升为主节点,减少不可用时间。

二、缓存击穿:热点 key 过期,高并发同时打穿

缓存击穿指的是某个热点 key 在过期瞬间,有大量并发请求同时查询这个 key,由于 key 刚过期,缓存中没有数据,所有请求同时打到数据库。

和雪崩的区别在于:雪崩是”大面积 key 失效”,击穿是”单个热点 key 失效”。

在电商系统里,这种情况常见于:

  • 某个爆款商品的缓存过期了;
  • 这个商品正在被大量用户同时查看;
  • 所有请求同时查数据库获取商品详情。
06 缓存雪崩、击穿、穿透 图 3

击穿的防护手段

1. 互斥锁(Mutex)

当缓存未命中时,不是直接查数据库,而是先尝试获取一个分布式锁。只有获取到锁的线程才能查询数据库并回填缓存,其他线程等待:

String value = redis.get(key);
if (value == null) {
    // 尝试获取锁
    boolean locked = redis.setnx(lockKey, "1", 10);
    if (locked) {
        try {
            // 只有拿到锁的线程查数据库
            value = loadFromDB(key);
            redis.setex(key, 300, value);
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁,短暂等待后重试缓存
        Thread.sleep(50);
        value = redis.get(key);
    }
}

互斥锁保证了同一时间只有一个线程回源数据库,其他线程要么等缓存回填,要么走降级逻辑。

2. 热点 key 永不过期 + 后台刷新

和防雪崩一样,热点 key 不设 TTL,后台异步刷新。从根本上消除”过期瞬间”。

3. 逻辑过期

给缓存值包装一个逻辑过期时间字段,查询时如果发现逻辑过期,则由一个后台线程异步更新,而当前请求先返回旧值。这样永远不会出现”缓存未命中”的情况。

{
  "data": { ...商品详情... },
  "expireAt": 1714800000000
}

三、缓存穿透:查询不存在的 key,缓存永远不中

缓存穿透指的是查询的 key 在数据库中也不存在,缓存自然也不会有这个 key,导致每次请求都会穿透缓存直达数据库。

和雪崩、击穿的区别在于:穿透的 key 是永远不存在的,不是过期了,而是压根就没有。

在电商系统里,这种情况常见于:

  • 恶意攻击:用随机商品 ID 大量请求,这些 ID 根本不存在;
  • 业务 bug:前端传了错误的参数,比如商品 ID 为 -1 或 null;
  • 数据被删除:某个商品下架了,但还有用户在访问。
06 缓存雪崩、击穿、穿透 图 4

穿透的防护手段

布隆过滤器防护

布隆过滤器这一段的重点也很简单:它不负责给你正确结果,它负责先挡掉一批“肯定不存在”的无效请求。

1. 布隆过滤器(Bloom Filter)

布隆过滤器是一种概率型数据结构,能告诉你”某个 key 肯定不存在”或”可能存在”。把所有存在的商品 ID 预先加载到布隆过滤器中,查询时先查过滤器:

// 初始化时加载所有有效商品 ID
BloomFilter<String> bloomFilter = BloomFilter.create(
    Funnels.stringFunnel(Charset.defaultCharset()),
    1000000,  // 预期元素数量
    0.01      // 误判率
);

// 加载现有商品
for (Long productId : allProductIds) {
    bloomFilter.put("product:" + productId);
}

// 查询时先查布隆过滤器
public String getProduct(String key) {
    if (!bloomFilter.mightContain(key)) {
        // 肯定不存在,直接返回 null
        return null;
    }
    // 可能存在,继续查缓存和数据库
    return queryWithCache(key);
}

布隆过滤器的特点是:判断”不存在”是 100% 准确的,判断”可能存在”有一定误判率。这意味着少量合法请求可能被误判为”可能存在”而走正常查询链路,但恶意构造的不存在 key 会被直接拦截。

2. 缓存空值

对于查询结果为空的情况,也把这个”空结果”缓存一段时间。这样同样的无效查询在一段时间内不会重复打到数据库:

String value = loadFromDB(key);
if (value == null) {
    // 缓存空值,TTL 设短一点,比如 60 秒
    redis.setex(key, 60, "__NULL__");
    return null;
}
redis.setex(key, 300, value);
return value;

空值的 TTL 要设得比正常值短,防止真实数据写入后长期被空值挡住。

3. 参数校验

在入口处就对参数做合法性校验。比如商品 ID 必须是正整数,范围在已知的业务边界内。不合法的请求直接返回错误,不走到缓存查询环节。

public Product getProduct(Long id) {
    if (id == null || id <= 0 || id > MAX_PRODUCT_ID) {
        return null; // 直接拦截
    }
    return queryWithCache("product:" + id);
}

4. 限流

对于明显异常的请求模式(如同一 IP 短时间内大量随机 ID 查询),通过限流器直接拒绝。

四、三种异常的对比总结

维度缓存雪崩缓存击穿缓存穿透
根本原因大量 key 同时过期/宕机热点 key 过期瞬间高并发查询的 key 不存在
影响范围大面积单个热点 key单个/多个不存在 key
数据库表现QPS 瞬间飙升同一查询并发飙升无效查询消耗资源
核心解决思路打散过期时间 + 高可用互斥锁 + 热点不过期布隆过滤器 + 缓存空值
预防优先级极高(涉及安全)

线上排查时,可以通过以下方式快速定位:

  • 看 Redis 监控:expired_keys 是否集中跳变 -> 可能是雪崩;
  • 看慢查询:大量相同 key 的查询 -> 可能是击穿;
  • 看数据库:大量查询返回空结果 -> 可能是穿透。

五、一个统一的防护框架

在实际项目中,往往不是单独防某一种异常,而是建立一个统一的防护框架:

用户请求
  -> 参数校验(防穿透)
  -> 布隆过滤器(防穿透)
  -> 本地缓存(防雪崩/击穿降级)
  -> Redis 缓存
    -> 互斥锁(防击穿)
    -> 数据库
  -> 熔断降级(防雪崩)
  -> 返回结果

这个框架的核心思想是分层防护:每一层解决不同的问题,多层配合才能构建健壮的缓存体系。

对于电商系统,建议的防护策略组合:

  • 所有查询:参数校验 + 布隆过滤器拦截明显无效请求;
  • 热点数据:不设 TTL,后台异步刷新;
  • 普通缓存:TTL 加随机偏移,避免集中过期;
  • 高并发场景:互斥锁防止击穿;
  • 整体架构:多级缓存 + 熔断降级 + Redis 高可用。

缓存异常不是”出了问题再修”,而是”设计阶段就要考虑”。等线上数据库被打挂了再补布隆过滤器,代价就太大了。